Skip to content

Conversation

@xerial
Copy link
Member

@xerial xerial commented Feb 9, 2026

Summary

  • Add CSS-class-based Transition system driven by Rx[Boolean] visibility, with enter/leave class sequences, double-rAF frame precision, and safety timeout fallbacks
  • Add ClickOutside detection via document-level mousedown listener with setTimeout(0) delay to avoid catching the opening click
  • Both features follow existing uni-dom patterns (FocusTrap RxElement pattern for Transition, DomNode binding pattern for ClickOutside)

Closes #409

Test plan

  • All 342 existing dom tests pass
  • 14 new Transition tests (config, factories, rendering, visibility)
  • 6 new ClickOutside tests (binding creation, DomNode usage)
  • scalafmtAll passes

🤖 Generated with Claude Code

Add CSS-class-based transition system (Transition) driven by Rx[Boolean]
visibility, and click outside detection (ClickOutside) for dismissing
dropdowns/modals. Closes #409.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions bot added the feature New feature label Feb 9, 2026
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @xerial, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the uni-dom library by providing robust solutions for common UI interaction patterns. It introduces a flexible system for animating the appearance and disappearance of DOM elements using CSS classes, and a reliable way to detect user clicks outside of a specific element. These additions empower developers to build more dynamic and user-friendly interfaces with reactive visibility control and intuitive dismissal behaviors.

Highlights

  • Element Transitions: Introduced a new CSS-class-based Transition system that allows for animating elements based on an Rx[Boolean] visibility state. It supports enter/leave class sequences, uses double requestAnimationFrame for precise timing, and includes safety timeout fallbacks.
  • Click Outside Detection: Added a ClickOutside detection mechanism that utilizes a document-level mousedown listener. This feature includes a setTimeout(0) delay to prevent immediate dismissal by the initial click that triggered the element's appearance, making it suitable for dropdowns and modals.
  • Integration with uni-dom patterns: Both new features are designed to integrate seamlessly with existing uni-dom reactive patterns, such as the RxElement pattern for Transitions and DomNode binding for ClickOutside, ensuring consistency and ease of use within the framework.
Changelog
  • uni-dom-test/src/test/scala/wvlet/uni/dom/ClickOutsideTest.scala
    • Added new test cases to verify the creation and functionality of ClickOutsideBinding.
  • uni-dom-test/src/test/scala/wvlet/uni/dom/TransitionTest.scala
    • Added comprehensive tests for TransitionConfig customization, Transition factory methods, and rendering behavior under various visibility states.
  • uni/.js/src/main/scala/wvlet/uni/dom/ClickOutside.scala
    • Introduced ClickOutsideBinding case class for handling outside click callbacks.
    • Implemented ClickOutside object with detect, hide, and onClickOutside methods for declarative outside click handling.
  • uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala
    • Added a new handleClickOutsideBinding method to DomRenderer to manage the lifecycle of document-level mousedown listeners for ClickOutsideBinding.
    • Integrated ClickOutsideBinding into the DomRenderer's binding handling logic.
  • uni/.js/src/main/scala/wvlet/uni/dom/Transition.scala
    • Defined TransitionConfig for customizing transition behavior, including name, duration, and initial appearance.
    • Created the Transition object with apply, fade, and slide factory methods to simplify transition creation.
    • Implemented TransitionElement to manage CSS class sequences (-enter-from, -enter-active, -enter-to, -leave-from, -leave-active, -leave-to) for element transitions, using requestAnimationFrame for precise timing and a safety timeout.
  • uni/.js/src/main/scala/wvlet/uni/dom/all.scala
    • Exported Transition, TransitionConfig, ClickOutside, and ClickOutsideBinding to make them easily accessible.
Activity
  • The author, xerial, implemented two new features: Element Transitions and Click Outside detection.
  • New unit tests were added for both ClickOutside (6 tests) and Transition (14 tests) functionalities.
  • All existing 342 DOM tests passed, and scalafmtAll was run successfully, indicating code quality and compatibility.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces valuable Transition and ClickOutside features to uni-dom. The implementation is well-structured and follows the existing patterns in the library. The accompanying tests are thorough and cover the new functionalities well. I've identified a couple of potential race conditions and a null-safety issue in the new code. Addressing these points will enhance the robustness of these new features. Overall, this is a great contribution.

Comment on lines +801 to +803
val target = me.target.asInstanceOf[dom.Node]
if !elem.contains(target) then
callback(me)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The event.target property can be null in some edge cases. The current code's asInstanceOf[dom.Node] on a null value would result in null, and !elem.contains(null) evaluates to true, incorrectly triggering the onClickOutside callback. It's safer to add a null check to prevent this.

                  val targetNode = me.target.asInstanceOf[dom.Node]
                  if targetNode != null && !elem.contains(targetNode) then
                    callback(me)

Comment on lines +252 to +281
private def waitForTransitionEnd(elem: dom.HTMLElement)(onEnd: () => Unit): Unit =
val timeoutMs = config.duration.getOrElse(5000)

val listener: js.Function1[dom.Event, Unit] =
(_: dom.Event) =>
pendingTimeout.foreach(id => dom.window.clearTimeout(id))
pendingTimeout = js.undefined
elem.removeEventListener("transitionend", transitionListener.get)
elem.removeEventListener("animationend", transitionListener.get)
transitionListener = js.undefined
onEnd()
transitionListener = listener

elem.addEventListener("transitionend", listener)
elem.addEventListener("animationend", listener)

// Safety timeout fallback
pendingTimeout = dom
.window
.setTimeout(
() =>
transitionListener.foreach { l =>
elem.removeEventListener("transitionend", l)
elem.removeEventListener("animationend", l)
}
transitionListener = js.undefined
onEnd()
,
timeoutMs
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of waitForTransitionEnd has race conditions related to the use of the shared transitionListener variable:

  1. If the safety timeout fires before the transitionend event, it clears transitionListener. When transitionend then fires, transitionListener.get will throw a NoSuchElementException.
  2. If a new transition starts on the same element, transitionListener is updated. If an event (either transitionend or timeout) from the old transition fires after this, it will incorrectly remove the listener of the new transition.

To fix this, the listener should be a lazy val to be able to reference itself, and both the event handler and the timeout handler should use this local listener for removal and check against the shared transitionListener before modifying it.

  private def waitForTransitionEnd(elem: dom.HTMLElement)(onEnd: () => Unit): Unit =
    val timeoutMs = config.duration.getOrElse(5000)

    lazy val listener: js.Function1[dom.Event, Unit] = { (_: dom.Event) =>
      pendingTimeout.foreach(id => dom.window.clearTimeout(id))
      pendingTimeout = js.undefined
      elem.removeEventListener("transitionend", listener)
      elem.removeEventListener("animationend", listener)
      // Only clear the shared listener if it's still this one
      if (transitionListener.toOption.contains(listener)) {
        transitionListener = js.undefined
      }
      onEnd()
    }
    transitionListener = listener

    elem.addEventListener("transitionend", listener)
    elem.addEventListener("animationend", listener)

    // Safety timeout fallback
    pendingTimeout = dom
      .window
      .setTimeout(
        () => {
          // Only act if the listener hasn't been replaced by a new transition
          if (transitionListener.toOption.contains(listener)) {
            elem.removeEventListener("transitionend", listener)
            elem.removeEventListener("animationend", listener)
            transitionListener = js.undefined
            onEnd()
          }
        },
        timeoutMs
      )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

uni-dom: Feature ideas for browser API coverage

1 participant